--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 7f99d82f21351e292900f1ad9ef3a6a7d9738beb
Parents : 4bd787c
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-25T18:19:39-05:00
feat: various fixes
Changes
37 files changed, 528 insertions(+), 161 deletions(-)
Diff
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index ef162a21..75c984b3 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -13,8 +13,8 @@ body:
id: version
attributes:
label: MeshChatX version
- description: The version shown in Settings or the release you installed (for example 4.8.1).
- placeholder: "4.8.1"
+ description: The version shown in Settings or the release you installed (for example 4.8.2).
+ placeholder: "4.8.2"
validations:
required: true
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index 13e26d29..2cff0f6f 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -14,7 +14,7 @@ body:
attributes:
label: MeshChatX version
description: The version you are using today, if relevant.
- placeholder: "4.8.1"
+ placeholder: "4.8.2"
- type: dropdown
id: os
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d8e4618f..655633fa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,16 @@
All notable changes to this project will be documented in this file.
+## [4.8.2] - TBD
+
+### Fixed
+
+- **Android LXST / Codec2**: Preload libcodec2 after the Chaquopy runtime starts, reload LXST Codec2 bindings when a soft import left Codec2 unset, and probe pycodec2 before mesh imports so Codec2 voice profiles work instead of reporting no codec on device.
+- **Android calls**: Clarify that the web audio bridge on Android uses native mic and speaker through the telephone audio bridge, not browser getUserMedia.
+- **Android RNode flasher**: Bluetooth Allow and Open settings open the system permission UI when runtime permission is missing or permanently denied. The capabilities banner shows Allow Bluetooth only when needed and otherwise steers users to the native flasher for USB.
+- **Browser calls (Docker / HTTPS)**: Refresh Devices calls getUserMedia first so Brave and Chromium show the microphone permission prompt instead of failing early when enumerateDevices lists no inputs before permission is granted.
+- **HTTP security headers**: Send Permissions-Policy allowing microphone and camera for this origin so reverse proxies that omit the header do not block capture by default.
+
## [4.8.1] - 2026-07-25
### Fixed
diff --git a/README.md b/README.md
index 5565e325..a900c8e2 100644
--- a/README.md
+++ b/README.md
@@ -441,7 +441,7 @@ task build
## Versioning
-Current version in this repo is `4.8.1`.
+Current version in this repo is `4.8.2`.
- **`package.json`** `version` is the only value you edit for a release bump.
- Run **`pnpm run version:sync`** (also run at the start of **`pnpm run build`**) to propagate that version into **`pyproject.toml`**, **`meshchatx/src/version.py`**, **`THIRD_PARTY_NOTICES.txt`** (product line), **README** / **lang/README.\*** “current version” lines, **`docs/meshchatx_on_raspberry_pi.md`** pipx example, and **`packaging/arch/PKGBUILD`** helpers.
diff --git a/android/app/build.gradle b/android/app/build.gradle
index d8a6f180..c228b7d2 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -50,8 +50,8 @@ android {
applicationId "com.meshchatx"
minSdk 24
targetSdk 35
- versionCode 48001
- versionName "4.8.1"
+ versionCode 48002
+ versionName "4.8.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {
diff --git a/android/app/src/main/java/com/meshchatx/MainActivity.java b/android/app/src/main/java/com/meshchatx/MainActivity.java
index 98779631..04e4b024 100644
--- a/android/app/src/main/java/com/meshchatx/MainActivity.java
+++ b/android/app/src/main/java/com/meshchatx/MainActivity.java
@@ -594,6 +594,12 @@ public class MainActivity extends AppCompatActivity {
addIfMissing(missingPermissions, Manifest.permission.POST_NOTIFICATIONS);
}
if (!missingPermissions.isEmpty()) {
+ for (String permission : missingPermissions) {
+ if (Manifest.permission.BLUETOOTH_CONNECT.equals(permission)
+ || Manifest.permission.BLUETOOTH_SCAN.equals(permission)) {
+ markBluetoothPermissionPrompted(permission);
+ }
+ }
ActivityCompat.requestPermissions(
this,
missingPermissions.toArray(new String[0]),
@@ -641,28 +647,56 @@ public class MainActivity extends AppCompatActivity {
void openAppPermissionSettings() {
try {
- startActivity(
- new Intent(
- Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
- Uri.parse("package:" + getPackageName())
- )
- );
+ Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
+ intent.setData(Uri.fromParts("package", getPackageName(), null));
+ startActivity(intent);
} catch (ActivityNotFoundException ignored) {
- Toast.makeText(this, "App settings unavailable", Toast.LENGTH_SHORT).show();
+ try {
+ startActivity(new Intent(Settings.ACTION_MANAGE_APPLICATIONS_SETTINGS));
+ } catch (ActivityNotFoundException ignoredAgain) {
+ Toast.makeText(this, "App settings unavailable", Toast.LENGTH_SHORT).show();
+ }
}
}
private static final String PREF_BT_PERM_PROMPTED_PREFIX = "bt_perm_prompted_";
boolean wasBluetoothPermissionDeniedPermanently(String permission) {
- // After the first prompt, if rationale is false and still denied, treat as permanent.
+ if (ContextCompat.checkSelfPermission(this, permission)
+ == PackageManager.PERMISSION_GRANTED) {
+ return false;
+ }
+ // No rationale means either never asked or permanently denied. Once we have
+ // prompted (startup or explicit), treat no-rationale as permanent deny.
boolean prompted =
getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
.getBoolean(PREF_BT_PERM_PROMPTED_PREFIX + permission, false);
- return prompted
- && ContextCompat.checkSelfPermission(this, permission)
- != PackageManager.PERMISSION_GRANTED
- && !ActivityCompat.shouldShowRequestPermissionRationale(this, permission);
+ if (!prompted) {
+ return false;
+ }
+ return !ActivityCompat.shouldShowRequestPermissionRationale(this, permission);
+ }
+
+ boolean isBluetoothPermanentlyDenied() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+ return false;
+ }
+ String[] needed = new String[] {
+ Manifest.permission.BLUETOOTH_CONNECT,
+ Manifest.permission.BLUETOOTH_SCAN,
+ };
+ for (String permission : needed) {
+ if (ContextCompat.checkSelfPermission(this, permission)
+ == PackageManager.PERMISSION_GRANTED) {
+ continue;
+ }
+ // After we have prompted once, denied + no rationale means permanent deny.
+ // requestPermissions would show no dialog.
+ if (wasBluetoothPermissionDeniedPermanently(permission)) {
+ return true;
+ }
+ }
+ return false;
}
void markBluetoothPermissionPrompted(String permission) {
@@ -769,6 +803,14 @@ public class MainActivity extends AppCompatActivity {
}
}
final boolean ok = granted;
+ if (!ok && isBluetoothPermanentlyDenied()) {
+ openAppPermissionSettings();
+ Toast.makeText(
+ this,
+ "Bluetooth blocked. Enable it in app settings.",
+ Toast.LENGTH_LONG
+ ).show();
+ }
if (webView != null) {
webView.evaluateJavascript(
"window.dispatchEvent(new CustomEvent('meshchatx-android-permission',"
@@ -783,6 +825,20 @@ public class MainActivity extends AppCompatActivity {
if (requestCode != RUNTIME_PERMISSIONS_REQUEST_CODE) {
return;
}
+ // Startup BT deny with no rationale: mark permanent path so later Allow
+ // Bluetooth opens settings instead of a silent no-op request.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+ && permissions != null
+ && grantResults != null) {
+ for (int i = 0; i < permissions.length && i < grantResults.length; i++) {
+ String permission = permissions[i];
+ if (!Manifest.permission.BLUETOOTH_CONNECT.equals(permission)
+ && !Manifest.permission.BLUETOOTH_SCAN.equals(permission)) {
+ continue;
+ }
+ markBluetoothPermissionPrompted(permission);
+ }
+ }
requestBatteryOptimizationExemptionIfNeeded();
completePendingWebPermissionRequestFromRuntimeState();
}
@@ -1658,23 +1714,15 @@ public class MainActivity extends AppCompatActivity {
if (hasBluetoothPermissions()) {
return "granted";
}
- final String[] needed = new String[] {
- Manifest.permission.BLUETOOTH_CONNECT,
- Manifest.permission.BLUETOOTH_SCAN,
- };
- boolean canPrompt = false;
- for (String permission : needed) {
- if (ContextCompat.checkSelfPermission(activity, permission)
- != PackageManager.PERMISSION_GRANTED) {
- if (ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)
- || !activity.wasBluetoothPermissionDeniedPermanently(permission)) {
- canPrompt = true;
- break;
- }
- }
- }
- if (!canPrompt) {
- activity.runOnUiThread(activity::openAppPermissionSettings);
+ if (activity.isBluetoothPermanentlyDenied()) {
+ activity.runOnUiThread(() -> {
+ activity.openAppPermissionSettings();
+ Toast.makeText(
+ activity,
+ "Bluetooth blocked. Enable it in app settings.",
+ Toast.LENGTH_LONG
+ ).show();
+ });
return "settings";
}
activity.runOnUiThread(() -> {
diff --git a/android/app/src/main/java/com/meshchatx/MeshChatApplication.java b/android/app/src/main/java/com/meshchatx/MeshChatApplication.java
index 20b4a7a3..85501496 100644
--- a/android/app/src/main/java/com/meshchatx/MeshChatApplication.java
+++ b/android/app/src/main/java/com/meshchatx/MeshChatApplication.java
@@ -26,33 +26,71 @@ public class MeshChatApplication extends PyApplication {
@Override
public void onCreate() {
- // Preload libcodec2.so into the process before Chaquopy imports pycodec2.
- // pycodec2.so NEEDED libcodec2.so has no RPATH so dlopen must already
- // resolve it (jniLibs or System.loadLibrary).
- preloadCodec2NativeLibrary();
+ // PyApplication sets up native library paths. Preload Codec2 after that
+ // so System.loadLibrary and absolute System.load can resolve jniLibs.
super.onCreate();
appContext = getApplicationContext();
+ preloadCodec2NativeLibrary();
createNotificationChannels();
}
private void preloadCodec2NativeLibrary() {
+ String nativeDir = null;
+ try {
+ nativeDir = getApplicationInfo().nativeLibraryDir;
+ if (nativeDir != null && !nativeDir.isEmpty()) {
+ android.system.Os.setenv("MESHCHAT_NATIVE_LIB_DIR", nativeDir, true);
+ }
+ } catch (Exception e) {
+ android.util.Log.w(
+ "MeshChatX",
+ "Could not set MESHCHAT_NATIVE_LIB_DIR: " + e.getMessage()
+ );
+ }
+ java.io.File absoluteLib = null;
+ if (nativeDir != null && !nativeDir.isEmpty()) {
+ absoluteLib = new java.io.File(nativeDir, "libcodec2.so");
+ if (absoluteLib.isFile()) {
+ try {
+ android.system.Os.setenv(
+ "MESHCHAT_LIBCODEC2_PATH",
+ absoluteLib.getAbsolutePath(),
+ true
+ );
+ } catch (Exception e) {
+ android.util.Log.w(
+ "MeshChatX",
+ "Could not set MESHCHAT_LIBCODEC2_PATH: " + e.getMessage()
+ );
+ }
+ } else {
+ absoluteLib = null;
+ }
+ }
try {
System.loadLibrary("codec2");
+ android.util.Log.i("MeshChatX", "Loaded libcodec2 via System.loadLibrary");
} catch (UnsatisfiedLinkError e) {
android.util.Log.w(
"MeshChatX",
"System.loadLibrary(codec2) failed before Python start: " + e.getMessage()
);
}
+ // Absolute System.load helps some linkers expose the SONAME for later
+ // Python ctypes / extension dlopen even after loadLibrary succeeded.
+ if (absoluteLib == null) {
+ return;
+ }
try {
- String nativeDir = getApplicationInfo().nativeLibraryDir;
- if (nativeDir != null && !nativeDir.isEmpty()) {
- android.system.Os.setenv("MESHCHAT_NATIVE_LIB_DIR", nativeDir, true);
- }
- } catch (Exception e) {
+ System.load(absoluteLib.getAbsolutePath());
+ android.util.Log.i(
+ "MeshChatX",
+ "Loaded libcodec2 via System.load(" + absoluteLib.getAbsolutePath() + ")"
+ );
+ } catch (UnsatisfiedLinkError e) {
android.util.Log.w(
"MeshChatX",
- "Could not set MESHCHAT_NATIVE_LIB_DIR: " + e.getMessage()
+ "System.load(libcodec2.so) failed: " + e.getMessage()
);
}
}
diff --git a/android/app/src/main/java/com/meshchatx/rnode/RNodeFlasherActivity.java b/android/app/src/main/java/com/meshchatx/rnode/RNodeFlasherActivity.java
index eaac336e..f0cbaa01 100644
--- a/android/app/src/main/java/com/meshchatx/rnode/RNodeFlasherActivity.java
+++ b/android/app/src/main/java/com/meshchatx/rnode/RNodeFlasherActivity.java
@@ -1,7 +1,9 @@
package com.meshchatx.rnode;
import android.Manifest;
+import android.content.ActivityNotFoundException;
import android.content.Intent;
+import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
@@ -50,6 +52,8 @@ import okhttp3.ResponseBody;
public final class RNodeFlasherActivity extends AppCompatActivity implements UsbSerialHub.Listener {
private static final int REQ_BT = 4401;
private static final String LOCAL_API = "https://127.0.0.1:8000";
+ private static final String PREFS = "rnode_flasher";
+ private static final String PREF_BT_PROMPTED = "bt_perm_prompted";
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private final ExecutorService io = Executors.newSingleThreadExecutor();
@@ -182,11 +186,94 @@ public final class RNodeFlasherActivity extends AppCompatActivity implements Usb
}
}
appendLog(ok ? "Bluetooth permission granted." : "Bluetooth permission denied.");
- Toast.makeText(
- this,
- ok ? "Bluetooth allowed" : "Bluetooth denied",
- Toast.LENGTH_SHORT
- ).show();
+ if (ok) {
+ Toast.makeText(this, "Bluetooth allowed", Toast.LENGTH_SHORT).show();
+ return;
+ }
+ if (isBluetoothPermanentlyDenied()) {
+ appendLog("Bluetooth permanently denied. Opening app settings.");
+ Toast.makeText(
+ this,
+ "Bluetooth blocked. Enable it in app settings.",
+ Toast.LENGTH_LONG
+ ).show();
+ openAppSettings();
+ return;
+ }
+ Toast.makeText(this, "Bluetooth denied", Toast.LENGTH_SHORT).show();
+ }
+
+ private boolean isBluetoothPermanentlyDenied() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+ return false;
+ }
+ String[] needed = new String[] {
+ Manifest.permission.BLUETOOTH_CONNECT,
+ Manifest.permission.BLUETOOTH_SCAN,
+ };
+ SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
+ boolean prompted = prefs.getBoolean(PREF_BT_PROMPTED, false);
+ for (String permission : needed) {
+ if (ContextCompat.checkSelfPermission(this, permission)
+ == PackageManager.PERMISSION_GRANTED) {
+ continue;
+ }
+ if (prompted && !ActivityCompat.shouldShowRequestPermissionRationale(this, permission)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void requestBluetooth() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+ appendLog("Bluetooth runtime permission not required on this Android version.");
+ Toast.makeText(this, "Bluetooth already allowed on this Android version", Toast.LENGTH_SHORT)
+ .show();
+ return;
+ }
+ List<String> missing = new ArrayList<>();
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT)
+ != PackageManager.PERMISSION_GRANTED) {
+ missing.add(Manifest.permission.BLUETOOTH_CONNECT);
+ }
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN)
+ != PackageManager.PERMISSION_GRANTED) {
+ missing.add(Manifest.permission.BLUETOOTH_SCAN);
+ }
+ if (missing.isEmpty()) {
+ appendLog("Bluetooth permission already granted.");
+ Toast.makeText(this, "Bluetooth already allowed", Toast.LENGTH_SHORT).show();
+ return;
+ }
+ if (isBluetoothPermanentlyDenied()) {
+ appendLog("Bluetooth permanently denied. Opening app settings.");
+ Toast.makeText(
+ this,
+ "Bluetooth blocked. Enable it in app settings.",
+ Toast.LENGTH_LONG
+ ).show();
+ openAppSettings();
+ return;
+ }
+ getSharedPreferences(PREFS, MODE_PRIVATE).edit().putBoolean(PREF_BT_PROMPTED, true).apply();
+ ActivityCompat.requestPermissions(this, missing.toArray(new String[0]), REQ_BT);
+ appendLog("Requesting Bluetooth permissions…");
+ }
+
+ private void openAppSettings() {
+ try {
+ Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
+ intent.setData(Uri.fromParts("package", getPackageName(), null));
+ startActivity(intent);
+ } catch (ActivityNotFoundException e) {
+ try {
+ startActivity(new Intent(Settings.ACTION_MANAGE_APPLICATIONS_SETTINGS));
+ } catch (ActivityNotFoundException ignored) {
+ Toast.makeText(this, "App settings unavailable", Toast.LENGTH_SHORT).show();
+ appendLog("App settings unavailable: " + e.getMessage());
+ }
+ }
}
private void refreshPorts() {
@@ -228,38 +315,6 @@ public final class RNodeFlasherActivity extends AppCompatActivity implements Usb
}
}
- private void requestBluetooth() {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
- appendLog("Bluetooth runtime permission not required on this Android version.");
- return;
- }
- List<String> missing = new ArrayList<>();
- if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT)
- != PackageManager.PERMISSION_GRANTED) {
- missing.add(Manifest.permission.BLUETOOTH_CONNECT);
- }
- if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN)
- != PackageManager.PERMISSION_GRANTED) {
- missing.add(Manifest.permission.BLUETOOTH_SCAN);
- }
- if (missing.isEmpty()) {
- appendLog("Bluetooth permission already granted.");
- Toast.makeText(this, "Bluetooth already allowed", Toast.LENGTH_SHORT).show();
- return;
- }
- ActivityCompat.requestPermissions(this, missing.toArray(new String[0]), REQ_BT);
- appendLog("Requesting Bluetooth permissions…");
- }
-
- private void openAppSettings() {
- startActivity(
- new Intent(
- Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
- Uri.parse("package:" + getPackageName())
- )
- );
- }
-
private void downloadFirmware() {
ProductCatalog.Product product = selectedProduct();
ProductCatalog.Model model = selectedModel();
diff --git a/android/app/src/main/python/meshchat_wrapper.py b/android/app/src/main/python/meshchat_wrapper.py
index 23aced20..51ee1444 100644
--- a/android/app/src/main/python/meshchat_wrapper.py
+++ b/android/app/src/main/python/meshchat_wrapper.py
@@ -167,23 +167,38 @@ def start_server(port=8000, app_files_dir=None, activity=None):
asyncio_signal_patch = _patch_asyncio_signal_handlers_for_android()
aiohttp_run_app_patch = _patch_aiohttp_run_app_for_android()
_patch_rns_panic_for_android()
- _install_android_rnode_support(activity)
+ # Codec2 must be ready before any import that pulls LXST.Codecs (soft-import
+ # locks Codec2=None for the process if pycodec2 fails on first import).
try:
from meshchatx.android_codec2 import (
ensure_codec2_native_library,
+ ensure_lxst_codec2_binding,
probe_pycodec2,
)
- ensure_codec2_native_library()
+ ensure_codec2_native_library(force=True)
ok, err = probe_pycodec2()
if ok:
- print("meshchat_wrapper: Codec2/pycodec2 ready")
+ bound = ensure_lxst_codec2_binding()
+ print(
+ "meshchat_wrapper: Codec2/pycodec2 ready"
+ + (" (LXST bound)" if bound else " (LXST bind deferred)")
+ )
else:
print(f"meshchat_wrapper: Codec2/pycodec2 unavailable: {err}")
except Exception as codec2_exc:
print(f"meshchat_wrapper: Codec2 preload skipped: {codec2_exc}")
+ _install_android_rnode_support(activity)
from meshchatx.meshchat import ReticulumMeshChat, main
+ try:
+ from meshchatx.android_codec2 import ensure_lxst_codec2_binding
+
+ if ensure_lxst_codec2_binding():
+ print("meshchat_wrapper: LXST Codec2 binding confirmed after meshchat import")
+ except Exception as bind_exc:
+ print(f"meshchat_wrapper: LXST Codec2 bind skipped: {bind_exc}")
+
try:
from meshchatx.android_push_bridge import install_websocket_hook
diff --git a/docs/en/platform-guides/raspberry-pi.md b/docs/en/platform-guides/raspberry-pi.md
index 4c047942..88885a12 100644
--- a/docs/en/platform-guides/raspberry-pi.md
+++ b/docs/en/platform-guides/raspberry-pi.md
@@ -61,17 +61,17 @@ source ~/.profile
## 3) Install MeshChatX with pipx (recommended)
-Preferred option (recommended): install from a release wheel (4.8.1 or newer),
+Preferred option (recommended): install from a release wheel (4.8.2 or newer),
because the wheel bundles frontend assets.
```bash
pipx install /path/to/reticulum_meshchatx-<version>-py3-none-any.whl
```
-Direct example (v4.8.1):
+Direct example (v4.8.2):
```bash
-pipx install "https://github.com/Quad4-Software/MeshChatX/releases/download/v4.8.1/reticulum_meshchatx-4.8.1-py3-none-any.whl"
+pipx install "https://github.com/Quad4-Software/MeshChatX/releases/download/v4.8.2/reticulum_meshchatx-4.8.2-py3-none-any.whl"
```
`py3-none-any` wheels are architecture-independent, so the same wheel artifact
@@ -93,7 +93,7 @@ cd ~/meshchatx
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
-python -m pip install "https://github.com/Quad4-Software/MeshChatX/releases/download/v4.8.1/reticulum_meshchatx-4.8.1-py3-none-any.whl"
+python -m pip install "https://github.com/Quad4-Software/MeshChatX/releases/download/v4.8.2/reticulum_meshchatx-4.8.2-py3-none-any.whl"
```
Run command in venv mode:
diff --git a/lang/README.de.md b/lang/README.de.md
index 26f3afb4..63f6affb 100644
--- a/lang/README.de.md
+++ b/lang/README.de.md
@@ -352,7 +352,7 @@ task build
## Versionierung
-Aktuelle Version in diesem Repository: `4.8.1`.
+Aktuelle Version in diesem Repository: `4.8.2`.
- Fuer Release-Bumps bearbeiten Sie **nur** `version` in **`package.json`**.
- **`pnpm run version:sync`** (wird auch zu Beginn von **`pnpm run build`** ausgefuehrt) verbreitet diese Version in **`pyproject.toml`**, **`meshchatx/src/version.py`**, **`THIRD_PARTY_NOTICES.txt`** (Produktzeile), **README** / **lang/README.\*** (Zeilen mit aktueller Version), **`docs/meshchatx_on_raspberry_pi.md`** (pipx-Beispiel) und Hilfsfelder in **`packaging/arch/PKGBUILD`**.
diff --git a/lang/README.it.md b/lang/README.it.md
index 265ef69d..feb3cd48 100644
--- a/lang/README.it.md
+++ b/lang/README.it.md
@@ -352,7 +352,7 @@ I target `Makefile` sono wrapper sottili che delegano a `task` (stessi comandi d
## Versionamento
-Versione attuale nel repository: `4.8.1`.
+Versione attuale nel repository: `4.8.2`.
- L'unico valore che modifichi per un bump di release e **`version` in `package.json`**.
- Esegui **`pnpm run version:sync`** (all'inizio anche di **`pnpm run build`**) per propagare in **`pyproject.toml`**, **`meshchatx/src/version.py`**, **`THIRD_PARTY_NOTICES.txt`** (riga prodotto), **README** / **lang/README.\*** (righe "versione attuale"), **esempio pipx in `docs/meshchatx_on_raspberry_pi.md`**, e aiuti in **`packaging/arch/PKGBUILD`**.
diff --git a/lang/README.ja.md b/lang/README.ja.md
index 5fc4a7fb..7715eea8 100644
--- a/lang/README.ja.md
+++ b/lang/README.ja.md
@@ -352,7 +352,7 @@ task build
## バージョン管理
-このリポジトリの現在のバージョンは `4.8.1` です。
+このリポジトリの現在のバージョンは `4.8.2` です。
- リリースのバージョン上げは **`package.json` の `version` のみ**編集します。
- **`pnpm run version:sync`**(**`pnpm run build`** 開始時にも実行)で、**`pyproject.toml`**、**`meshchatx/src/version.py`**、**`THIRD_PARTY_NOTICES.txt`**(製品行)、**README** / **lang/README.\***(現在のバージョン行)、**`docs/meshchatx_on_raspberry_pi.md`** の pipx 例、**`packaging/arch/PKGBUILD`** の補助フィールドに反映します。
diff --git a/lang/README.ru.md b/lang/README.ru.md
index e4924b04..b21d5946 100644
--- a/lang/README.ru.md
+++ b/lang/README.ru.md
@@ -352,7 +352,7 @@ task build
## Версионирование
-Текущая версия в репозитории: `4.8.1`.
+Текущая версия в репозитории: `4.8.2`.
- Редактируйте для релизного бампа **только** поле `version` в **`package.json`**.
- Команда **`pnpm run version:sync`** (также в начале **`pnpm run build`**) распространяет эту версию в **`pyproject.toml`**, **`meshchatx/src/version.py`**, **`THIRD_PARTY_NOTICES.txt`** (строка продукта), **README** / **lang/README.\*** (строки «текущая версия»), **`docs/meshchatx_on_raspberry_pi.md`** (пример pipx) и вспомогательные поля **`packaging/arch/PKGBUILD`**.
diff --git a/lang/README.zh.md b/lang/README.zh.md
index a3b2a40e..85850856 100644
--- a/lang/README.zh.md
+++ b/lang/README.zh.md
@@ -352,7 +352,7 @@ task build
## 版本管理
-本仓库当前版本: `4.8.1`。
+本仓库当前版本: `4.8.2`。
- 发布版本号**只**改 **`package.json` 的 `version`**。
- 运行 **`pnpm run version:sync`**(在 **`pnpm run build`** 开头也会执行)可将该版本同步到 **`pyproject.toml`**、**`meshchatx/src/version.py`**、**`THIRD_PARTY_NOTICES.txt`**(产品行)、**README** / **lang/README.\*** 中的“当前版本”行、**`docs/meshchatx_on_raspberry_pi.md`** 的 pipx 示例,以及 **`packaging/arch/PKGBUILD`** 的辅助字段。
diff --git a/meshchatx.rsm b/meshchatx.rsm
index 15fbd3df..f89614f9 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/__init__.py b/meshchatx/__init__.py
index 3abc356e..3dd131ed 100644
--- a/meshchatx/__init__.py
+++ b/meshchatx/__init__.py
@@ -3,7 +3,7 @@
"""Reticulum MeshChatX - A mesh network communications app."""
# Synced from package.json via scripts/sync_version.js (also writes meshchatx/src/version.py).
-__version__ = "4.8.1"
+__version__ = "4.8.2"
# LXST vendored pyogg can NameError on import when libopus is present but
# libogg is not. Apply before any meshchatx module imports LXST.
try:
diff --git a/meshchatx/android_codec2.py b/meshchatx/android_codec2.py
index 3f97013c..dab4b0f6 100644
--- a/meshchatx/android_codec2.py
+++ b/meshchatx/android_codec2.py
@@ -5,6 +5,7 @@
from __future__ import annotations
import ctypes
+import importlib
import logging
import os
import sys
@@ -13,7 +14,8 @@ from pathlib import Path
logger = logging.getLogger(__name__)
_codec2_preload_error: str | None = None
-_codec2_preload_done = False
+_codec2_preload_ok = False
+_codec2_preload_attempted = False
def _is_chaquopy_android() -> bool:
@@ -86,7 +88,19 @@ def _java_system_load_library(name: str = "codec2") -> bool:
return False
-def ensure_codec2_native_library() -> bool:
+def _java_system_load_absolute(path: Path) -> bool:
+ """Load an absolute .so path via Java System.load."""
+ try:
+ from java.lang import System as JavaSystem
+
+ JavaSystem.load(str(path))
+ return True
+ except Exception as exc:
+ logger.debug("Java System.load(%s) failed: %s", path, exc)
+ return False
+
+
+def ensure_codec2_native_library(*, force: bool = False) -> bool:
"""Preload libcodec2.so so import pycodec2 works on Android.
Chaquopy installs chaquopy-libcodec2 separately from pycodec2. The
@@ -94,26 +108,33 @@ def ensure_codec2_native_library() -> bool:
preloading or bundling the shared library next to pycodec2.so, imports
fail at runtime with dlopen errors.
- Order: Java System.loadLibrary (jniLibs), bare CDLL name, then absolute
- candidate paths including MESHCHAT_NATIVE_LIB_DIR.
+ Always prefer absolute-path loads with RTLD_GLOBAL even after a successful
+ Java System.loadLibrary. On some Android or Chaquopy linker setups the
+ Java load alone is not enough for the Python extension dlopen.
"""
- global _codec2_preload_done, _codec2_preload_error
+ global _codec2_preload_attempted, _codec2_preload_error, _codec2_preload_ok
- if _codec2_preload_done:
- return _codec2_preload_error is None
+ if _codec2_preload_ok and not force:
+ return True
+ if _codec2_preload_attempted and not force and _codec2_preload_error is not None:
+ # Previous attempt failed. Allow one more try when force=True only.
+ return False
- _codec2_preload_done = True
+ _codec2_preload_attempted = True
+ _codec2_preload_error = None
if not _is_chaquopy_android():
+ _codec2_preload_ok = True
return True
+ loaded_any = False
if _java_system_load_library("codec2"):
logger.info("Loaded Codec2 via Java System.loadLibrary(codec2)")
- return True
+ loaded_any = True
try:
_cdll_load("libcodec2.so")
- return True
+ loaded_any = True
except OSError:
pass
@@ -121,13 +142,23 @@ def ensure_codec2_native_library() -> bool:
for lib_path in _libcodec2_candidates():
if not lib_path.is_file():
continue
+ if _java_system_load_absolute(lib_path):
+ loaded_any = True
+ logger.info("Loaded Codec2 via Java System.load(%s)", lib_path)
try:
_cdll_load(str(lib_path))
logger.info("Loaded Codec2 native library from %s", lib_path)
- return True
+ loaded_any = True
+ break
except OSError as exc:
last_error = f"{lib_path}: {exc}"
+ if loaded_any:
+ _codec2_preload_ok = True
+ _codec2_preload_error = None
+ return True
+
+ _codec2_preload_ok = False
_codec2_preload_error = last_error or "libcodec2.so not found on Android"
logger.warning("Codec2 native preload failed: %s", _codec2_preload_error)
return False
@@ -136,7 +167,9 @@ def ensure_codec2_native_library() -> bool:
def probe_pycodec2() -> tuple[bool, str | None]:
"""Import pycodec2 after preload and report whether Codec2 works."""
if _is_chaquopy_android() and not ensure_codec2_native_library():
- return False, codec2_preload_error()
+ # Retry once in case native libs appeared after an early failed attempt.
+ if not ensure_codec2_native_library(force=True):
+ return False, codec2_preload_error()
try:
import pycodec2
@@ -147,6 +180,35 @@ def probe_pycodec2() -> tuple[bool, str | None]:
return False, str(exc)
+def ensure_lxst_codec2_binding() -> bool:
+ """Ensure LXST.Codecs.Codec2 is bound after a successful pycodec2 probe.
+
+ Soft-import patches set Codec2 to None when the first LXST.Codecs import
+ fails. Reload Codecs after preload so telephony can use Codec2 modes.
+ """
+ ok, _err = probe_pycodec2()
+ if not ok:
+ return False
+ try:
+ codecs_mod = importlib.import_module("LXST.Codecs")
+
+ if getattr(codecs_mod, "Codec2", None) is not None:
+ return True
+ codecs_mod = importlib.reload(codecs_mod)
+ if getattr(codecs_mod, "Codec2", None) is None:
+ return False
+ # Refresh Telephony bindings that may have imported Codec2 as None.
+ try:
+ telephony_mod = importlib.import_module("LXST.Primitives.Telephony")
+ importlib.reload(telephony_mod)
+ except Exception as tel_exc:
+ logger.debug("LXST Telephony reload skipped: %s", tel_exc)
+ return True
+ except Exception as exc:
+ logger.warning("LXST Codec2 binding failed: %s", exc)
+ return False
+
+
def codec2_preload_error() -> str | None:
"""Return the last preload failure message, if any."""
return _codec2_preload_error
@@ -154,6 +216,7 @@ def codec2_preload_error() -> str | None:
def reset_codec2_preload_state_for_tests() -> None:
"""Clear preload memoization (tests only)."""
- global _codec2_preload_done, _codec2_preload_error
- _codec2_preload_done = False
+ global _codec2_preload_attempted, _codec2_preload_error, _codec2_preload_ok
+ _codec2_preload_attempted = False
+ _codec2_preload_ok = False
_codec2_preload_error = None
diff --git a/meshchatx/src/backend/data/THIRD_PARTY_NOTICES.txt b/meshchatx/src/backend/data/THIRD_PARTY_NOTICES.txt
index e4e85765..20611a20 100644
--- a/meshchatx/src/backend/data/THIRD_PARTY_NOTICES.txt
+++ b/meshchatx/src/backend/data/THIRD_PARTY_NOTICES.txt
@@ -86,7 +86,7 @@ pycparser 3.0
pyserial 3.5
License: BSD
Author: Chris Liechti
-reticulum-meshchatx 4.8.1
+reticulum-meshchatx 4.8.2
License: 0BSD AND MIT
Author: Quad4
rns 1.4.1
diff --git a/meshchatx/src/backend/data/licenses_backend.json b/meshchatx/src/backend/data/licenses_backend.json
index ebb12da2..69dcc163 100644
--- a/meshchatx/src/backend/data/licenses_backend.json
+++ b/meshchatx/src/backend/data/licenses_backend.json
@@ -157,7 +157,7 @@
},
{
"name": "reticulum-meshchatx",
- "version": "4.8.1",
+ "version": "4.8.2",
"author": "Quad4",
"license": "0BSD AND MIT"
},
diff --git a/meshchatx/src/backend/http/middleware.py b/meshchatx/src/backend/http/middleware.py
index f19360b7..ec2c75e7 100644
--- a/meshchatx/src/backend/http/middleware.py
+++ b/meshchatx/src/backend/http/middleware.py
@@ -276,6 +276,9 @@ def create_security_middleware(app):
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
+ # Explicitly allow mic/camera for this origin. Reverse proxies that omit
+ # Permissions-Policy are fine. This documents intent for Brave and Chromium.
+ response.headers["Permissions-Policy"] = "microphone=(self), camera=(self)"
# CSP base configuration
privacy_mode = privacy_mode_enabled(app.config)
diff --git a/meshchatx/src/backend/telephone_manager.py b/meshchatx/src/backend/telephone_manager.py
index c2075828..3277fe6d 100644
--- a/meshchatx/src/backend/telephone_manager.py
+++ b/meshchatx/src/backend/telephone_manager.py
@@ -111,8 +111,7 @@ class TelephoneManager:
from meshchatx import android_codec2
if android_codec2._is_chaquopy_android():
- ok, _err = android_codec2.probe_pycodec2()
- if not ok:
+ if not android_codec2.ensure_lxst_codec2_binding():
return False
except Exception:
pass
diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue
index deca93e9..66cfd32f 100644
--- a/meshchatx/src/frontend/components/call/CallPage.vue
+++ b/meshchatx/src/frontend/components/call/CallPage.vue
@@ -699,7 +699,12 @@
@update:model-value="onToggleWebAudio"
/>
<div class="text-xs text-gray-500 dark:text-zinc-400 px-1">
- <template v-if="webAudioBridgeRequired">
+ <template v-if="isMeshChatXAndroid()">
+ Required on Android. Calls use the native mic and
+ speaker attached through the audio bridge (not the
+ browser getUserMedia path).
+ </template>
+ <template v-else-if="webAudioBridgeRequired">
Required on this host (no LXST host audio device).
Browser mic and speaker are used for calls.
</template>
@@ -2311,17 +2316,18 @@ export default {
try {
return await mediaDevices.getUserMedia(constraints);
} catch (e) {
- if (
- (e?.name === "NotFoundError" || e?.name === "OverconstrainedError") &&
- constraints?.audio &&
- typeof constraints.audio === "object" &&
- constraints.audio.deviceId
- ) {
- this.selectedAudioInputId = null;
- this.logWebAudioFailure("getUserMedia-fallback-wide", e);
- return await mediaDevices.getUserMedia({ audio: true });
+ const retryable =
+ e?.name === "NotFoundError" ||
+ e?.name === "OverconstrainedError" ||
+ e?.name === "NotReadableError";
+ if (!retryable) {
+ throw e;
}
- throw e;
+ // Stale exact deviceId, Brave pre-permission device lists, or busy device.
+ // Wide-open audio is what actually triggers the browser permission prompt.
+ this.selectedAudioInputId = "__meshchat_default_in__";
+ this.logWebAudioFailure("getUserMedia-fallback-wide", e);
+ return await mediaDevices.getUserMedia({ audio: true });
}
},
logWebAudioFailure(stage, error) {
@@ -2479,6 +2485,8 @@ export default {
);
return;
}
+ // Enumerate after permission when possible. Pre-permission refresh
+ // only keeps Default placeholders (blank labels / ids).
await this.refreshAudioDevices();
const stream = await this.getUserMediaWithMicFallback(mediaDevices);
this.audioStream = stream;
@@ -2705,21 +2713,18 @@ export default {
if (!mediaDevices) {
throw new Error("navigator.mediaDevices is unavailable");
}
- if (this.hasEnumerateDevicesApi(mediaDevices)) {
- try {
- const devices = await mediaDevices.enumerateDevices();
- const hasAudioInput = devices.some((d) => d.kind === "audioinput");
- if (devices.length > 0 && !hasAudioInput) {
- ToastUtils.error(this.$t("call.no_audio_input_found"));
- return false;
- }
- } catch (enumErr) {
- this.logWebAudioFailure("enumerate-devices-preflight", enumErr);
- }
- }
-
- await this.refreshAudioDevices();
- const stream = await this.getUserMediaWithMicFallback(mediaDevices);
+ // Do not gate on enumerateDevices before getUserMedia. Brave and
+ // Chromium often omit audioinput (or only list speakers) until the
+ // mic permission prompt has been accepted. Calling getUserMedia
+ // first is what shows the browser permission dialog.
+ this.selectedAudioInputId = "__meshchat_default_in__";
+ const stream = await mediaDevices.getUserMedia({
+ audio: {
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: true,
+ },
+ });
stream.getTracks().forEach((t) => t.stop());
await this.refreshAudioDevices();
return true;
@@ -2763,19 +2768,33 @@ export default {
const devices = await mediaDevices.enumerateDevices();
let inputs = devices.filter((d) => d.kind === "audioinput");
let outputs = devices.filter((d) => d.kind === "audiooutput");
- if (inputs.length === 0 || inputs.every((d) => !d.deviceId || String(d.deviceId).trim() === "")) {
+ // Pre-permission lists often have blank deviceId and blank labels.
+ // Keep the Default placeholder so we do not lock onto phantom IDs.
+ const inputsUsable = inputs.some(
+ (d) => d.deviceId && String(d.deviceId).trim() !== "" && d.label
+ );
+ if (!inputsUsable) {
inputs = [defaultIn];
}
- if (outputs.length === 0 || outputs.every((d) => !d.deviceId || String(d.deviceId).trim() === "")) {
+ const outputsUsable = outputs.some(
+ (d) => d.deviceId && String(d.deviceId).trim() !== "" && d.label
+ );
+ if (!outputsUsable) {
outputs = [defaultOut];
}
this.audioInputDevices = inputs;
this.audioOutputDevices = outputs;
- if (!this.selectedAudioInputId && this.audioInputDevices.length) {
- this.selectedAudioInputId = this.audioInputDevices[0].deviceId;
+ const selectedInStillValid = this.audioInputDevices.some(
+ (d) => d.deviceId === this.selectedAudioInputId
+ );
+ if (!selectedInStillValid) {
+ this.selectedAudioInputId = this.audioInputDevices[0]?.deviceId || defaultIn.deviceId;
}
- if (!this.selectedAudioOutputId && this.audioOutputDevices.length) {
- this.selectedAudioOutputId = this.audioOutputDevices[0].deviceId;
+ const selectedOutStillValid = this.audioOutputDevices.some(
+ (d) => d.deviceId === this.selectedAudioOutputId
+ );
+ if (!selectedOutStillValid) {
+ this.selectedAudioOutputId = this.audioOutputDevices[0]?.deviceId || defaultOut.deviceId;
}
} catch (e) {
this.logWebAudioFailure("refresh-devices", e);
diff --git a/meshchatx/src/frontend/components/rnode/RNodeCapabilitiesBanner.vue b/meshchatx/src/frontend/components/rnode/RNodeCapabilitiesBanner.vue
index 7c9fd0d0..a2c9dec5 100644
--- a/meshchatx/src/frontend/components/rnode/RNodeCapabilitiesBanner.vue
+++ b/meshchatx/src/frontend/components/rnode/RNodeCapabilitiesBanner.vue
@@ -103,7 +103,12 @@ export default {
},
_bluetoothActions() {
const actions = [];
- if (this.androidAvailable) {
+ if (!this.androidAvailable) {
+ return actions;
+ }
+ const bluetooth = this.capabilities?.transports?.[TRANSPORT_BLUETOOTH];
+ const needsPermission = bluetooth?.reason === "android_bluetooth_permission_required";
+ if (needsPermission) {
actions.push({
id: "request-bluetooth",
icon: "bluetooth-settings",
@@ -114,6 +119,18 @@ export default {
icon: "cog",
labelKey: "tools.rnode_flasher.support.actions.open_settings",
});
+ } else {
+ // Permissions granted (or N/A). WebView still cannot flash over BLE.
+ actions.push({
+ id: "open-native-flasher",
+ icon: "usb",
+ labelKey: "tools.rnode_flasher.support.actions.open_native",
+ });
+ actions.push({
+ id: "open-bluetooth-settings",
+ icon: "cog",
+ labelKey: "tools.rnode_flasher.support.actions.open_settings",
+ });
}
return actions;
},
diff --git a/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue b/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue
index 97cf0ecf..f3fb07c3 100644
--- a/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue
+++ b/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue
@@ -321,7 +321,12 @@ export default {
return;
}
if (action === "open-bluetooth-settings") {
- this.androidBridge.openBluetoothSettings();
+ if (this.androidBridge.openBluetoothSettings()) {
+ ToastUtils.info(this.$t("tools.rnode_flasher.support.actions.bluetooth_open_settings"));
+ } else {
+ ToastUtils.warning(this.$t("tools.rnode_flasher.support.actions.bluetooth_unsupported"));
+ }
+ return;
}
},
async fetchLatestRelease() {
diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/raspberry-pi.md b/meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/raspberry-pi.md
index 4c047942..88885a12 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/raspberry-pi.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/raspberry-pi.md
@@ -61,17 +61,17 @@ source ~/.profile
## 3) Install MeshChatX with pipx (recommended)
-Preferred option (recommended): install from a release wheel (4.8.1 or newer),
+Preferred option (recommended): install from a release wheel (4.8.2 or newer),
because the wheel bundles frontend assets.
```bash
pipx install /path/to/reticulum_meshchatx-<version>-py3-none-any.whl
```
-Direct example (v4.8.1):
+Direct example (v4.8.2):
```bash
-pipx install "https://github.com/Quad4-Software/MeshChatX/releases/download/v4.8.1/reticulum_meshchatx-4.8.1-py3-none-any.whl"
+pipx install "https://github.com/Quad4-Software/MeshChatX/releases/download/v4.8.2/reticulum_meshchatx-4.8.2-py3-none-any.whl"
```
`py3-none-any` wheels are architecture-independent, so the same wheel artifact
@@ -93,7 +93,7 @@ cd ~/meshchatx
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
-python -m pip install "https://github.com/Quad4-Software/MeshChatX/releases/download/v4.8.1/reticulum_meshchatx-4.8.1-py3-none-any.whl"
+python -m pip install "https://github.com/Quad4-Software/MeshChatX/releases/download/v4.8.2/reticulum_meshchatx-4.8.2-py3-none-any.whl"
```
Run command in venv mode:
diff --git a/meshchatx/src/version.py b/meshchatx/src/version.py
index 726eccb0..45084d6a 100644
--- a/meshchatx/src/version.py
+++ b/meshchatx/src/version.py
@@ -3,4 +3,4 @@
Do not edit by hand. Run: pnpm run version:sync
"""
-__version__ = "4.8.1"
+__version__ = "4.8.2"
diff --git a/package.json b/package.json
index b89a5755..74086c41 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "reticulum-meshchatx",
- "version": "4.8.1",
+ "version": "4.8.2",
"description": "A simple mesh network communications app powered by the Reticulum Network Stack",
"homepage": "https://github.com/Quad4-Software/MeshChatX",
"desktopName": "reticulum-meshchatx.desktop",
diff --git a/packaging/arch/.SRCINFO b/packaging/arch/.SRCINFO
index 5bb2c0a4..ecee50eb 100644
--- a/packaging/arch/.SRCINFO
+++ b/packaging/arch/.SRCINFO
@@ -1,6 +1,6 @@
pkgbase = reticulum-meshchatx-git
pkgdesc = A simple mesh network communications app powered by the Reticulum Network Stack
- pkgver = 4.8.1.r0.gebacc00
+ pkgver = 4.8.2.r0.gebacc00
pkgrel = 1
url = https://github.com/Quad4-Software/MeshChatX
arch = x86_64
diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD
index a7150bcb..8ccf68f8 100644
--- a/packaging/arch/PKGBUILD
+++ b/packaging/arch/PKGBUILD
@@ -1,7 +1,7 @@
# Maintainer: Ivan <ivan@quad4.io>
pkgname=reticulum-meshchatx-git
_pkgname=reticulum-meshchatx
-pkgver=4.8.1.r0.gebacc00
+pkgver=4.8.2.r0.gebacc00
pkgrel=1
pkgdesc="A simple mesh network communications app powered by the Reticulum Network Stack"
arch=('x86_64' 'aarch64')
@@ -19,7 +19,7 @@ sha256sums=('SKIP'
pkgver() {
cd "$_pkgname"
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
- printf "4.8.1.r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
+ printf "4.8.2.r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
}
prepare() {
diff --git a/pyproject.toml b/pyproject.toml
index eaf9ab2a..eab73fd0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "reticulum-meshchatx"
-version = "4.8.1"
+version = "4.8.2"
description = "A simple mesh network communications app powered by the Reticulum Network Stack"
authors = [
{name = "Quad4"}
diff --git a/tests/backend/test_android_codec2.py b/tests/backend/test_android_codec2.py
index 3efcd5ce..ba73ff80 100644
--- a/tests/backend/test_android_codec2.py
+++ b/tests/backend/test_android_codec2.py
@@ -48,11 +48,15 @@ def test_ensure_codec2_prefers_java_load_library():
patch.object(
android_codec2, "_java_system_load_library", return_value=True
) as java_load,
+ patch.object(android_codec2, "_java_system_load_absolute", return_value=False),
patch.object(android_codec2, "_cdll_load") as cdll,
+ patch.object(android_codec2, "_libcodec2_candidates", return_value=[]),
):
+ # Bare soname CDLL may still fail after Java load. Absolute candidates empty.
+ cdll.side_effect = [OSError("no bare soname")]
assert android_codec2.ensure_codec2_native_library() is True
java_load.assert_called_once_with("codec2")
- cdll.assert_not_called()
+ assert cdll.call_count >= 1
assert android_codec2.codec2_preload_error() is None
@@ -82,7 +86,8 @@ def test_libcodec2_candidates_find_without_importing_pycodec2(tmp_path, monkeypa
def test_probe_pycodec2_reports_failure_when_import_breaks():
android_codec2.reset_codec2_preload_state_for_tests()
- android_codec2._codec2_preload_done = True
+ android_codec2._codec2_preload_attempted = True
+ android_codec2._codec2_preload_ok = True
android_codec2._codec2_preload_error = None
with (
patch.object(android_codec2, "_is_chaquopy_android", return_value=False),
@@ -103,6 +108,33 @@ def test_probe_pycodec2_reports_failure_when_import_breaks():
assert err
+def test_ensure_lxst_codec2_binding_reloads_when_codec2_none():
+ import types
+
+ android_codec2.reset_codec2_preload_state_for_tests()
+
+ fake_codecs = types.ModuleType("LXST.Codecs")
+ fake_codecs.Codec2 = None
+
+ reloaded = types.ModuleType("LXST.Codecs")
+ reloaded.Codec2 = object()
+
+ def import_module(name):
+ if name == "LXST.Codecs":
+ return fake_codecs
+ if name == "LXST.Primitives.Telephony":
+ raise ImportError("telephony skipped in test")
+ raise ImportError(name)
+
+ with (
+ patch.object(android_codec2, "probe_pycodec2", return_value=(True, None)),
+ patch.object(android_codec2.importlib, "import_module", side_effect=import_module),
+ patch.object(android_codec2.importlib, "reload", return_value=reloaded) as reload_mock,
+ ):
+ assert android_codec2.ensure_lxst_codec2_binding() is True
+ assert reload_mock.called
+
+
def test_vendor_wheels_bundle_libcodec2_for_all_abis():
import zipfile
diff --git a/tests/backend/test_csp_logic.py b/tests/backend/test_csp_logic.py
index 473c8fdc..7d8936e4 100644
--- a/tests/backend/test_csp_logic.py
+++ b/tests/backend/test_csp_logic.py
@@ -68,6 +68,7 @@ async def test_csp_header_logic(mock_rns_minimal, tmp_path):
assert "https://tiles.example.com" in csp
assert "default-src 'self'" in csp
assert "wasm-unsafe-eval" in csp
+ assert response.headers.get("Permissions-Policy") == "microphone=(self), camera=(self)"
m = re.search(r"script-src([^;]+);", csp)
assert m is not None and "blob:" in m.group(1)
script_src = m.group(1)
diff --git a/tests/frontend/CallPage.test.js b/tests/frontend/CallPage.test.js
index d7505215..195efdf6 100644
--- a/tests/frontend/CallPage.test.js
+++ b/tests/frontend/CallPage.test.js
@@ -417,10 +417,44 @@ describe("CallPage.vue", () => {
const mediaDevices = { getUserMedia, enumerateDevices: vi.fn().mockResolvedValue([]) };
const stream = await wrapper.vm.getUserMediaWithMicFallback(mediaDevices);
expect(getUserMedia).toHaveBeenCalledTimes(2);
- expect(wrapper.vm.selectedAudioInputId).toBeNull();
+ expect(wrapper.vm.selectedAudioInputId).toBe("__meshchat_default_in__");
expect(stream).toBe(fakeStream);
});
+ it("requestAudioPermission prompts getUserMedia even when enumerate lists only speakers", async () => {
+ const wrapper = mountCallPage();
+ await flushPromises();
+ const stop = vi.fn();
+ const getUserMedia = vi.fn().mockResolvedValue({ getTracks: () => [{ stop }] });
+ const enumerateDevices = vi.fn().mockResolvedValue([
+ { kind: "audiooutput", deviceId: "", label: "", groupId: "" },
+ ]);
+ const mediaDevicesDescriptor = Object.getOwnPropertyDescriptor(navigator, "mediaDevices");
+ Object.defineProperty(navigator, "mediaDevices", {
+ configurable: true,
+ value: { getUserMedia, enumerateDevices },
+ });
+ try {
+ await expect(wrapper.vm.requestAudioPermission()).resolves.toBe(true);
+ expect(getUserMedia).toHaveBeenCalledTimes(1);
+ expect(getUserMedia.mock.calls[0][0]).toEqual({
+ audio: {
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: true,
+ },
+ });
+ expect(stop).toHaveBeenCalled();
+ expect(enumerateDevices).toHaveBeenCalled();
+ } finally {
+ if (mediaDevicesDescriptor) {
+ Object.defineProperty(navigator, "mediaDevices", mediaDevicesDescriptor);
+ } else {
+ Reflect.deleteProperty(navigator, "mediaDevices");
+ }
+ }
+ });
+
it("pickWebAudioMicConstraints includes browser audio processing hints", async () => {
const wrapper = mountCallPage();
await flushPromises();
diff --git a/tests/frontend/RNodeCapabilities.test.js b/tests/frontend/RNodeCapabilities.test.js
index 271507d8..57794533 100644
--- a/tests/frontend/RNodeCapabilities.test.js
+++ b/tests/frontend/RNodeCapabilities.test.js
@@ -64,6 +64,16 @@ describe("Capabilities.detectCapabilities", () => {
expect(caps.transports[TRANSPORT_BLUETOOTH].reason).toBe("android_bluetooth_permission_required");
});
+ it("reports android_bridge_no_web_bluetooth when MeshChatXAndroid already granted BT", () => {
+ const env = mkEnv({
+ navigator: { userAgent: "Android" },
+ MeshChatXAndroid: { hasBluetoothPermissions: () => true },
+ });
+ const caps = detectCapabilities({ env });
+ expect(caps.transports[TRANSPORT_BLUETOOTH].available).toBe(false);
+ expect(caps.transports[TRANSPORT_BLUETOOTH].reason).toBe("android_bridge_no_web_bluetooth");
+ });
+
it("reports bluetooth available when navigator.bluetooth is present", () => {
const env = mkEnv({ navigator: { userAgent: "x", bluetooth: {} } });
const caps = detectCapabilities({ env });
diff --git a/tests/frontend/RNodeComponents.test.js b/tests/frontend/RNodeComponents.test.js
index ae3ab78d..9d211775 100644
--- a/tests/frontend/RNodeComponents.test.js
+++ b/tests/frontend/RNodeComponents.test.js
@@ -52,7 +52,25 @@ describe("RNodeCapabilitiesBanner", () => {
});
expect(wrapper.text()).toContain("tools.rnode_flasher.support.bluetooth.title");
const labels = wrapper.findAll("button").map((b) => b.text());
+ expect(labels.some((l) => l.includes("open_native"))).toBe(true);
+ expect(labels.some((l) => l.includes("open_settings"))).toBe(true);
+ expect(labels.some((l) => l.includes("request_bluetooth"))).toBe(false);
+ });
+
+ it("shows request bluetooth when android permission is required", () => {
+ const env = {
+ isSecureContext: true,
+ navigator: { userAgent: "Android" },
+ MeshChatXAndroid: { hasBluetoothPermissions: () => false },
+ };
+ const caps = detectCapabilities({ env });
+ const wrapper = mountWith(RNodeCapabilitiesBanner, {
+ capabilities: caps,
+ androidAvailable: true,
+ });
+ const labels = wrapper.findAll("button").map((b) => b.text());
expect(labels.some((l) => l.includes("request_bluetooth"))).toBe(true);
+ expect(labels.some((l) => l.includes("open_settings"))).toBe(true);
});
});
diff --git a/uv.lock b/uv.lock
index 56ff55a2..102cffed 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2000,7 +2000,7 @@ wheels = [
[[package]]
name = "reticulum-meshchatx"
-version = "4.8.1"
+version = "4.8.2"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────